Fix: detect L3 worker child exit instead of spinning forever - #1003
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughChild PIDs now flow through hierarchical worker registration into local mailbox endpoints. Endpoints periodically inspect child status during task and control waits, report exit details, poison failed control mailboxes, and are covered by a fork-based regression test. ChangesChild liveness detection
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Worker
participant WorkerManager
participant LocalMailboxEndpoint
participant ChildProcess
Worker->>WorkerManager: register mailbox and child_pid
WorkerManager->>LocalMailboxEndpoint: construct endpoint with child_pid
LocalMailboxEndpoint->>ChildProcess: waitpid(WNOHANG)
ChildProcess-->>LocalMailboxEndpoint: exit status
LocalMailboxEndpoint-->>WorkerManager: report endpoint failure
WorkerManager-->>Worker: return child-death error
Possibly related PRs
Poem
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces child process PID tracking and liveness checks to prevent the parent process from spin-polling indefinitely if a child worker dies. The review feedback highlights several critical improvements: replacing the Python 3.9-incompatible strict=True in zip() with manual length checks, improving the robustness of check_child_alive in C++ (such as handling EINTR and correcting child_reaped_ logic), and resetting the mailbox state to IDLE when exceptions are thrown during polling to prevent deadlocks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
7aa5071 to
109b63e
Compare
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the hw-native-sys#1003 / hw-native-sys#980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the hw-native-sys#1003 / hw-native-sys#980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the #1003 / #980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI.
109b63e to
7616dd6
Compare
A chip or sub child that exits before publishing TASK_DONE / CONTROL_DONE left the parent spin-polling its mailbox indefinitely, with the child stuck as <defunct> (issue hw-native-sys#980). Neither spin loop had any liveness check, so the only exit was the control path's timeout — and the task dispatch path had no timeout at all. Plumb the forked child pid from the Python Worker through the nanobind bindings into LocalMailboxEndpoint, and sample the child's liveness with waitpid(WNOHANG) while waiting for mailbox completion. A dispatch whose child died is reported as an ENDPOINT_FAILURE completion; a control command throws and poisons the mailbox, since no later command on a dead child could complete either. The liveness probe retries on EINTR, treats any other waitpid failure as "no information" and keeps polling, and only records the child as dead once it was actually reaped or observed unwaitable. The exit status is retained so every later operation on that endpoint reports the same cause rather than silently resuming the spin. Based on the fix by ndleslx in hw-native-sys#1003, ported onto the WorkerEndpoint refactor. The teardown half of that change is no longer needed: every os.waitpid site in Worker close already tolerates ChildProcessError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Heads-up: I force-pushed this branch, replacing Why I picked it up: the hang is still live on What changed:
On two review suggestions I deliberately did not apply: resetting the mailbox to Added Still marked draft; flip it to ready when you're happy with it. |
7616dd6 to
ed8e3b3
Compare
A chip or sub child that exits before publishing TASK_DONE / CONTROL_DONE left the parent spin-polling its mailbox indefinitely, with the child stuck as <defunct> (issue hw-native-sys#980). Neither spin loop had any liveness check, so the only exit was the control path's timeout — and the task dispatch path had no timeout at all. Plumb the forked child pid from the Python Worker through the nanobind bindings into LocalMailboxEndpoint, and sample the child's liveness with waitpid(WNOHANG) while waiting for mailbox completion. A dispatch whose child died is reported as an ENDPOINT_FAILURE completion; a control command throws and poisons the mailbox, since no later command on a dead child could complete either. The liveness probe retries on EINTR, treats any other waitpid failure as "no information" and keeps polling, and only records the child as dead once it was actually reaped or observed unwaitable. The exit status is retained so every later operation on that endpoint reports the same cause rather than silently resuming the spin. Based on the fix by ndleslx in hw-native-sys#1003, ported onto the WorkerEndpoint refactor. The teardown half of that change is no longer needed: every os.waitpid site in Worker close already tolerates ChildProcessError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Rebased again onto current CI on The two red checks are
The a5 self-hosted runner looks to be in a bad state repo-wide; worth a maintainer look independently of this PR. Every a5 job that does not need that runner ( |
Summary
Workerthrough the nanobind bindings intoLocalMailboxEndpointwaitpid(WNOHANG)while spin-polling forTASK_DONE/CONTROL_DONE, so a child that exits early is reported instead of spun on foreverENDPOINT_FAILUREcompletion on the task path; throw and poison the mailbox on the control pathMotivation
Fixes the L3 Worker hang from #980: a chip child could exit before publishing
TASK_DONE, leaving the parent polling the mailbox indefinitely and the child<defunct>.The hang is still present on
main—LocalMailboxEndpoint::runspin-pollsTASK_DONEwith no liveness check and no timeout, andworker_manager.cppcontains nowaitpidat all.Changes since the original commit
Rebased onto current
main(the branch was ~295 commits behind) and adapted to theWorkerThread→WorkerEndpoint/LocalMailboxEndpointrefactor, so the check now lives in the endpoint that actually owns the mailbox and both spin loops.Review feedback addressed in
check_child_death():waitpidonEINTRinstead of treating it as a fatal errorwaitpidfailure as "no information" and keep polling, rather than tearing down a live workerTwo adaptations to current
main:ENDPOINT_FAILUREcompletion (matching the current error model) rather than throwingmain, so it samples on a 10 ms wall-clock period; the task loop keeps the count-based interval (200 iterations × 50 us ≈ 10 ms)The mailbox is deliberately not reset to
IDLEbefore throwing: with the child gone, no later command can complete, so admitting one would restore the hang this check exists to break. The existingmailbox_control_timed_out_poison flag is reused instead.The original teardown change is dropped — every
os.waitpidsite inWorkerclose on currentmainalready toleratesChildProcessError.Testing
WorkerManagerTest.ControlCommandFailsWhenChildExitsBeforeCompletion: forks a real child that exits without publishingCONTROL_DONE. Verified it fails without the fix (hangs into the 10 s guard) and passes with it (10 ms).ctestC++ unit suite: 60/60 passedpytest tests/ut/py/test_worker tests/ut/py/test_chip_worker.py: 348 passed, 2 skippedtask-submit, a2a3):test_l3_dependency,test_l3_group,test_l3_host_buffer_registrationpassedexamples/workers/l3/child_memory/main.py -p a2a3onboard: golden + cross-invocation checks passedpre-commit(clang-format, clang-tidy, cpplint, ruff, pyright): all passedFixes #980